本章通过动手搭建一个基于 Vue.js 的 SSR 项目,深入学习 SSR 的实现,项目使用 webpack 4.x 打包项目和 Vuex 管理数据。
Vue SSR 介绍
什么是服务器端渲染 (SSR)
Vue SSR(Vue.js Server-Side Rendering)是 Vue.js 官方提供的一个服务端渲染(同构应用)解决方案,使用它可以构建同构应用,而Vue SSR依旧基于原有的 Vue.js 技术栈。
官方文档的解释:Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记”激活”为客户端上完全可交互的应用程序。 服务器渲染的 Vue.js 应用程序也可以被认为是”同构”或”通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。
使用场景
技术层面:
- 更快的首屏渲染速度
- 更好的 SEO
业务层面:
- 不适合管理系统
- 适合门户资讯类网站,例如企业官网、知乎、简书等
- 适合移动网站
如何实现 Vue SSR
- 基于 Vue SSR 官方文档提供的解决方案
官方方案具有更直接的控制应用程序的结构,更深入底层,更加灵活,同时在使用官方方案的过程中,也会对Vue SSR有更加深入的了解。
该方式需要熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。
- Nuxt.js 开发框架
Nuxt 提供了平滑的开箱即用的体验,它建立在同等的 Vue 技术栈之上,但抽象出很多模板,并提供了一些额外的功能,例如静态站点生成。通过 Nuxt.js 可以快速的使用 Vue SSR 构建同构应用。
Vue SSR 基本使用
接下来以 Vue SSR 的官方文档为参考,来学习一下它的基本用法。
渲染一个 Vue 实例
目的:了解下如何使用 Vue SSR 将一个 Vue 实例渲染为 HTML 字符串
首先,我们来学习一下服务端渲染中最基础的工作:模板渲染。 简单来说就是如何在服务端使用 Vue 的方式解析替换字符串。
在它的官方文档中其实已经给出了示例代码,下面我们来把这个案例的实现过程以及其中含义演示一下。
- 准备工作
1 | # 创建文件夹 |
- 创建
server.js
文件,将 Vue 实例渲染成 HTML 字符串
1 | // 第 1 步:创建一个 Vue 实例 |
- 使用 node 运行
server.js
1 | node server.js |
执行结果如下
通过渲染结果可以看到模板根节点中添加了data-server-rendered="true"
字段,主要用于将来客户端渲染激活接管的一个入口
与服务器集成
在 Node.js 服务器中使用时相当简单直接,例如 Express,具体使用如下:
- 首先,安装 Express 到项目中
1 | $ npm install express --save |
- 然后,使用 Express 创建一个基本的 Web 服务,在 Web 服务中渲染 Vue 实例
1 | // server.js |
- 启动 Web 服务,nodemon 启动服务,可以实时监听,热更新:
1 | $ nodemon server.js |
运行结果如下所示:
使用一个页面模板
将上面进一步优化,将模板提取为一个单独的页面
- 创建一个页面模板 index.html
1 |
|
注意:上面的 注释语句,是固定语法。会作为 Vue 实例 转换的 html 字符串存放的位置,类似于占位。vue-ssr-outlet 前后不可以有空格。
- 在
server.js
中,创建 renderer 渲染器时,添加一个 template 参数,指定模板
1 | ... |
通过运行结果可以看到,可以通过传入一个”渲染上下文对象”,作为 renderToString 函数的第二个参数,来提供插值数据,插入到模板中,但是服务端渲染只是把 vue 实例处理成纯静态的HTML字符串发送给客户端,在 vue 实例中,需要客户端动态交互的功能,本身并没有提供,例如<input v-model = "message">
input 标签通过 v-model 绑定数据 message,当input 发生改变的时候,message 也会改变,所有绑定message的位置也随之变化,通过测试发现改变 input 内容改变后 message 值并未发生变化。
原因:服务端渲染出来的文件并没有客户端 js 相关内容,所以目前没有客户端交互的能力,如何实现呢?下面将详细介绍
构建同构渲染 - 构建流程
如上图:左边为应用的源代码 Source ,中间为 Webpack ,右边为 NodeServer 服务端。
在我们的应用当中,目前只有 Server entry 服务端入口来处理服务端渲染,如果我们希望服务端渲染的内容拥有客户端动态交互的能力的话,还需要有一个客户端脚本的入口 Client entry,它专门来处理客户端渲染,也就是去接管服务端渲染的内容把它激活为一个动态页面。
有了这两个入口之后需要 Webpack 对它们进行打包编译构建:对于 Server entry 最终要打包为一个 ServerBundle 来做服务端渲染,同样的对于客户端入口 Client entry 来说最终要打包为一个ClientBundle来接管服务端渲染好的静态页面对它进行激活。
这就是同构应用实现的一个基本流程。
构建同构渲染 - 源码结构
我们需要使用 webpack 来打包我们的 Vue 应用程序。事实上,我们可能需要在服务器上使用 webpack 打包 Vue 应用程序,因为:
- 通常 Vue 应用程序是由 webpack 和 vue-loader 构建,并且许多 webpack 特定功能不能直接在 Node.js 中运行(例如通过 file-loader 导入文件,通过 css-loader 导入 CSS)。
- 尽管 Node.js 最新版本能够完全支持 ES2015 特性,我们还是需要转译客户端代码以适应老版浏览器。这也会涉及到构建步骤。
所以基本看法是,对于客户端应用程序和服务器应用程序,我们都要使用 webpack 打包-服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。
现在我们正在使用 webpack 来处理服务器和客户端的应用程序,大部分源码可以使用通用方式编写,可以使用 webpack 支持的所有功能。同时,在编写通用代码时,有一些事项要牢记在心。
项目中src目录的基本结构
src
├── pages
│ ├── Home.vue
│ ├── About.vue
│ └── Post.vue
├── App.vue # 根组件
├── app.js # 通用 entry(universal entry)
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器
App.vue
1 | <template> |
app.js
app.js
是我们应用程序的「通用 entry」。在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。但是,对于服务器端渲染(SSR),责任就会转移到纯客户端 entry 文件。app.js 简单地使用 export 导出一个 createApp 函数
1 | import Vue from 'vue' |
需要注意的是:避免状态单例
当编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是 Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。
如基本示例所示,我们为每个请求创建一个新的根 Vue 实例。这与每个用户在自己的浏览器中使用新应用程序的实例类似。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染 (cross-request state pollution)。
因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序、router 和 store 实例。
entry-client.js
客户端 entry 只需创建应用程序,并且将其挂载到 DOM 中
1 | // 客户端入口 |
entry-server.js
服务器 entry 使用 default export 导出函数,并在每次渲染中重复调用此函数。此时,除了创建和返回应用程序实例之外,它不会做太多事情, 但是稍后我们将在此执行服务器端路由匹配 (server-side route matching) 和数据预取逻辑 (data pre-fetching logic)
1 | // 服务端入口 |
构建同构渲染 - 构建配置
安装依赖
- 安装生产依赖
1 | $ npm i vue vue-server-renderer express cross-env |
包 | 说明 |
---|---|
vue | Vue.js 核心库 |
vue-server-renderer | Vue 服务端渲染工具 |
express | 基于 Node 的 Web 服务框架 |
cross-env | 通过 npm scripts 设置跨平台环境变量,区分不同的打包环境,使用process.env.NODE_ENV获取当前的环境 |
- 安装开发依赖
1 | $ npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin |
包 | 说明 |
---|---|
webpack | webpack 核心包 |
webpack-cli | ebpack 的命令行工具 |
webpack-merge | webpack 配置信息合并工具 |
webpack-node-externals | 排除 webpack 中的 Node 模块 |
rimraf | 基于 Node 封装的一个跨平台 rm -rf 工具 |
friendly-errors-webpack-plugin | 友好的 webpack 错误提示 |
@babel/core、@babel/plugin-transform-runtime、 @babel/preset-env、 babel-loader | Babel 相关工具(ES6转ES5) |
vue-loader、 vue-template-compiler | 处理 .vue 资源 |
file-loader | 处理字体资源 |
css-loader | 处理 CSS 资源 |
url-loader | 处理图片资源 |
配置文件及打包命令
初始化 webpack 打包配置文件
build ├── webpack.base.config.js # 公共配置 ├── webpack.client.config.js # 客户端打包配置文件 └── webpack.server.config.js # 服务端打包配置文件
webpack打包配置文件的代码详见 build
- 在 package.json 的 scripts 中配置打包命令
1 | "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js", |
- 运行测试
客户端打包,命令执行及输出结果如下:
1 | $ npm run build:client |
服务端打包,命令执行及输出结果如下:
1 | $ npm run build:server |
同时打包客户端和服务端,命令执行及输出结果如下:
1 | $ npm run build |
启动应用
- 参考网址:https://ssr.vuejs.org/zh/guide/bundle-renderer.html
server.js
1 | const Vue = require('vue') |
解析渲染流程
服务端渲染
服务端是如何渲染并输出 html 文件?服务端渲染主要结合 serverBundl e文件,具体的流程如下:
从路由(server.js)这里着手,当客户端请求进来时候会被服务端路由匹配到,并调用 renderer
渲染器的 renderToString 方法进行渲染,把一个 vue 实例渲染成 html 字符串发送给客户端,但是在 renderer.renderToString
方法中并没有看到 vue 实例,那么这里的 vue 实例是怎么获取到的?
通过代码可以看到 renderer
渲染器是通过 createBundleRenderer
方法创建出来,方法的第一个参数是 serverBundle 即打包生成的 vue-ssr-server-bundle.json
文件,文件的具体内容如下:
server Bundle
是 Vue SSR
构建的一个特殊的 JSON 文件,主要描述服务端打包的信息,包括如下内容:
- entry:服务端打包的入口(
server-bundle.js
在打包时候配置的文件名) - files:所有构建结果资源列表 (
server-bundle.js
: 即entry-server.js
构建出来的结果文件) - maps:源代码 source map 信息,主要在开发调试中使用
那么 serverBundle 是如何被使用的?
render 在渲染的时候,会来加载 serverbundle 中的入口 entry,加载里面的代码并执行,就可以得到 entry-server.js 中创建的vue实例,把 vue 实例进行渲染,把渲染结果注入到 template 模板中(render的第二个参数),最后把数据发送到了客户端。
客户端渲染
客户端如何接管激活服务端渲染后的内容?
通过客户端构建打包出来的js脚本文件注入到页面(index.html)中,加载客户端脚本,但是我们在模板文件中并没有写这些代码,而是在构建结果输出到客户端页面中自动包含了这些脚本
那么服务端是如何知道模板中需要引用加载客户端构建出来的结果呢?
通过代码可以看到,createBundleRenderer
中配置了参数 clientManifest
即 vue-ssr-client-manifest.json
,它是客户端打包资源的一个构建清单,清单中描述了客户端构建资源的相关信息,具体内容如下:
- publicPath:访问静态资源的根相对路径,与 webpack 配置中的 publicPath 一致
- all:客户端打包所有构建出来的静态资源名称
- initial:页面初始化时需要加载的文件,会在页面加载时配置到 preload(提前加载) 中(在渲染的时候,会把 initial 中的资源通过 script 方式自动的注入到模板页面的
<!--vue-ssr-outlet-->
之后) - async:页面跳转时需要加载的异步资源文件,会在页面加载时配置到 prefetch(预判加载) 中
- modules:项目的各个模块包含的文件的序号,对应 all 中文件的顺序,moduleIdentifier 和 all 数组中文件的映射关系(modules对象是我们查找文件引用的重要数据)
modules作用:当客户端在运行的时候,假如加载了一个模块,这个模块他用到那些资源,vue就会根据信息去加载这些资源
通过 script 方式引入到模板中的客户端打包的 app.js 文件是如何工作的?
参考官方文档:客户端激活 (client-side hydration)
服务端渲染好内容想要拥有动态交互能力,需要客户端激活,接管服务端渲染好的内容,使其变为由 Vue 管理的动态 DOM 的过程。
在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:
1 | // 这里假定 App.vue template 根元素的 `id="app"` |
由于服务器已经渲染好了 HTML,客户端不会在进行重新渲染。而是需要”激活”这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化),这个过程也称为“注水”
在浏览器中查看服务器渲染的输出结果,应用程序的根元素上添加了一个特殊的属性:
1 | <div id="app" data-server-rendered="true"> |
data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载,而不是通过完整的渲染模式,需要注意在根组件的根元素中需要添加 id="app"
,否则应用程序将无法正常激活。
在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):
1 | // 强制使用应用程序的激活模式 |
在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果匹配,就直接激活,无需重新渲染,如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。
构建同构渲染 - 构建配置开发模式
我们现在已经实现同构应用的基本功能了,但是这对于一个完整的应用来说还远远不够,例如如何处理同构应用中的路由、如何在服务端渲染中进行数据预取等功能。这些功能我们都会去对它进行实现,但是在实现它们之前我们先优化之前的代码,解决每次写完代码,都要重新打包构建,重新启动 Web 服务,让其变成一个编写完代码后自动构建并重启web服务器,自动刷新页面,下面我们来修改下:
基本思路
生产模式
- npm run build构建
- node server 启动应用
开发模式
- 监视代码变动自动构建,热更新等功能
- node server 启动应用
配置启动脚本package.json
1 | "start": "cross-env NODE_ENV=production node server.js", |
服务端配置 server.js
renderer 是通过打包的结果调用 createBundleRenderer 创建出来的。在生产模式下它是直接基于打包的结果创建的,但是在开发模式下 renderer 需要不断更新渲染。
1 | /** |
封装处理模块
build/setup-dev-server.js
,开发模式,自动构建,更新 Renderer 渲染器
1 | const fs = require('fs') |
处理模板文件(监视构建 template)
关于 Node 中的监视的问题:
- fs.watch
- fs.watchFile
- 第三方包:chokidar
fs.watch、fs.watchFile不太好使用,推荐使用第三方包 chokidar 监听文件的变化,其内部也是封装了原生的监视模块,性能和功能上比原生好。
安装第三方包:chokidar
1 | $ npm i -D chokidar |
build/setup-dev-server.js
,监视构建 template,更新 Renderer 渲染器
1 | // 处理模板文件:监视构建 template -> 调用 update -> 更新 Renderer 渲染器 |
服务端监视打包(监视构建 serverBundle)
build/setup-dev-server.js
,监视构建 serverBundle,更新 Renderer 渲染器
1 | // 服务端监视打包:监视构建 serverBundle -> 调用 update -> 更新 Renderer 渲染器 |
开发环境频繁的磁盘读写,速度慢,将打包结果存储到内存中,提高速度,如何将打包结果存储到内存中?
将打包结果存储到内存中
webpack 默认会把构建结果存储到磁盘中,对于生产模式构建来说是没有问题的;但是我们在开发模 式中会频繁的修改代码触发构建,也就意味着要频繁的操作磁盘数据,而磁盘数据操作相对来说是比较慢的,所以我们有一种更好的方式,就是 把数据存储到内存中,这样可以极大的提高构建的速度。两种方式:
- 方案一:自己配置 memfs
memfs 是一个兼容 Node 中 fs 模块 API 的内存文件系统,通过它我们可以轻松的实现把 webpack 构建结果输出到内存中进行管理。
- 安装第三方包:memfs
1 | $ npm install -D memfs |
build/setup-dev-server.js
,监视构建 serverBundle,更新 Renderer 渲染器
1 | const { createFsFromVolume, Volume } = require('memfs') |
- 方案二:使用 webpack-dev-middleware
webpack-dev-middleware
作用是,以监听模式启动 webpack,将编译结果输出到内存中,然后将内存文件输出到 Express 服务中。
- 安装依赖:webpack-dev-middleware
1 | $ npm i -D webpack-dev-middleware |
2.配置到构建流程中,build/setup-dev-server.js
1 | const devMiddleware = require('webpack-dev-middleware') |
客户端构建(监视构建 clientManifest)
客户端打包和服务端打包类似,都是借助于 webpack-dev-middleware
1 | const devMiddleware = require('webpack-dev-middleware') |
热更新
热更新功能需要使用到 webpack-hot-middleware 工具包
- 安装依赖
1 | $ npm install --save-dev webpack-hot-middleware |
build/setup-dev-server.js
配置热更新
1 | ... |
工作原理:
中间件将自身安装为 webpack 插件,并侦听编译器事件。
每个连接的客户端都有一个 Server Sent Events 连接,服务器将在编译器事件上向连接的客户端 发布通知。
MDN - 使用服务器发送事件
Server-Sent Events 教程
当客户端收到消息时,它将检查本地代码是否为最新。如果不是最新版本,它将触发 webpack 热更新模块重新加载。
编写通用代码
到此为止我们终于把实现 Vue SSR 同构应用的基础环境搭建起来,虽然还有很多不足之处,但是也能满足我们当前的基本使用了。接下来把内容的核心转移到 Vue SSR 本身上,了解一下编写通用应用的注意事项,对于如何正确的使用 Vue SSR 是非常有帮助的。
在这些注意事项中,有些其实已经在前面的学习过程中了解过了,而有些还没有接触过,所以在这里通 过官方文档做一个简单的总结
服务器上的数据相应
在纯客户端应用程序 (client-only app) 中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染 (cross-request state pollution),例如 app.js 代码
1 | // 导出一个工厂函数,用于创建新的应用程序、router、store实例 |
因为实际的渲染过程需要确定性,所以我们也将在服务器上“预取”数据 (“pre-fetching” data) - 这意味着在我们开始渲染时,我们的应用程序就已经解析完成其状态。也就是说,将数据进行响应式的过程在服务器上是多余的,所以默认情况下禁用。禁用响应式数据,还可以避免将「数据」转换为「响应式对象」的性能开销。
组件生命周期钩子函数
由于没有动态更新,所有的生命周期钩子函数中,只有 beforeCreate 和 created 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。
此外还需要注意的是,你应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端 (client-side only) 的代码中,我们可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,请将副作用代码移动到 beforeMount 或 mounted 生命周期中。
访问特定平台(Platform-Specific) API
通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 window 或 document,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。
对于共享于服务器和客户端,但用于不同平台 API 的任务(task),建议将平台特定实现包含在通用 API 中,或者使用为你执行此操作的 library。例如,axios 是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。
对于仅浏览器可用的 API,通常方式是,在「纯客户端 (client-only)」的生命周期钩子函数中惰性访问 (lazily access) 它们。
请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。你可能要通过模拟 (mock) 一些全局变量来使其正常运行,但这只是 hack 的做法,并且可能会干扰到其他 library 的环境检测代码。
区分运行环境
webpack DefinePlugin
区分运行环境:
1 | new webpack.DefinePlugin({ |
自定义指令
大多数自定义指令直接操作 DOM,因此会在服务器端渲染 (SSR) 过程中导致错误。有两种方法可以解决这个问题:
- 推荐使用组件作为抽象机制,并运行在「虚拟 DOM 层级(Virtual-DOM level)」(例如,使用渲染函数(render function))。
- 如果你有一个自定义指令,但是不是很容易替换为组件,则可以在创建服务器 renderer 时,使用 directives 选项所提供”服务器端版本(server-side version)”
路由和代码分割
接下来我们来了解如何处理通用应用中的路由,和纯客户端的使用方式基本一致,使用vue-router,只需要在少许的位置做一些配置即可。通过查看文档来具体演示下:
配置VueRouter - router/index.js
- 安装
1 | npm i vue-router |
- 配置 VueRouter (路由规则)
router/index.js
1 | import Vue from 'vue' |
将路由注册到根实例
- 注册路由
src/app.js
1 | // 通用 entry |
适配服务端入口
entry-server.js
1 | // 服务端启动入口 |
服务端适配
- 进行服务端 server 适配
server.js
1 | ... |
适配客户端入口
路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。
entry-client.js
1 | ... |
配置路由出口
根组件中,设置路由的出口src/App.vue
,如果没有路由出口的话,匹配到的路由组件就不知道要渲染到哪里
1 | <template> |
到此位置路由相关配置已经完成,可以启动项目测试
总结
测试路由导航,可以看到正常工作,那说明我们同构应用中的路由产生作用了。
现在我们的应用就非常的厉害了,当你首次访问页面的时候,它是通过服务端渲染出来的,服务端渲染拥有了更快的渲染速度以及更好的 SEO,当服务端把渲染的内容发送到客户端后,客户端通过 Vue 结合 Vue Router 把当前内容激活,摇身一变成为了一个客户端单页面 SPA 应用,之后的页面导航交互是通过客户端交互的方式处理,不需要重新刷新整个页面。这样我们的网站就既拥有了更好的渲染速度和SEO,也拥有了单页应用中更好的用户体验。
除此之外,我们在路由中配置的异步组件(也叫路由懒加载)也是非常有意义,这些异步组件会被分割为独立的 chunk(也就是单独的文件),只有在需要的时候才会进行加载。这样就能够避免在初始渲染的时候客户端加载的脚本过大导致激活速度变慢的问题。
我们可以来验证一下,通过 npm run build
打包构建,我们发现它们确实被分割成了独立的 chunk ,如下图所示:
然后再来看一下在运行期间这些chunk文件是如何加载的
我们会发现除了 app 主资源外,其它的资源也被下载下来了,这里可能会有个疑问:不是应该在需要的时候才加载吗?为什么一上来就加载了?
原因是在页面的头部中的带有 preload 和 prefetch 的 link 标签
我们期望客户端 JavaScript 脚本尽快加载尽早的接管服务端渲染的内容,让其拥有动态交互能力,但是如果把这些资源以 script 标签替代 link 标签这里这里的话,浏览器会去下载它,然后执行里面的代码,这个过程会阻塞页面的渲染。所以看到真正的 script 标签是在页面的底部的。
1 | <link rel="preload" href="/dist/app.js" as="script"> |
这部分代码是告诉浏览器可以去预加载这个资源。但是不要执行里面的代码,也不要影响网页的正常渲染。直到遇到真正的 script 标签加载该资源的时候才会去执行里面的代码,这个时候可能已经预加载好了,直接使用就可以了,如果没有加载好,也不会造成重复加载,所以不用担心这个问题。
- prefetch 资源是加载下一个页面可能用到的资源,浏览器会在空闲的时候对其进行加载,所以它并不一定会把资源加载出来
preload 当前页面一定会用到的资源,对其进行预加载。所以当我们去访问 about 页面的时候,它的资源是通过 prefetch 预取过来的(prefetch不稳定,页面的js可能是其加载好的),提高了客户端页面导航的响应速度。
管理页面 Head
无论是服务端渲染还是客户端渲染,它们都使用的同一个页面模板。页面中的 body 是动态渲染出来的,但是页面的 head 是写死的,如果我们希望不同的页面可以拥有自己的 head 内容,例如页面的 title、meta 等内容,如何处理呢? 下面我们来了解一下如何让不同的页面来定制自己的 head 头部内容。
官方文档这里专门描述了关于页面 Head 的处理,相对于来讲更原生一些,使用比较麻烦,有兴趣的同学可以了解一下。
我这里主要给大家介绍一个第三方解决方案:vue-meta
Vue Meta 是一个支持 SSR 的第三方 Vue.js 插件,可让你轻松的实现不同页面的 head 内容管理。 使用它的方式非常简单,而只需在页面组件中使用 metaInfo 属性配置页面的 head 内容即可。
使用 vue-meta 进行配置具体步骤如下:
- 安装 vue-meta 依赖
1 | npm i -D vue-meta |
- 在通用入口
app.js
中通过插件的方式将 vue-meta 注册到 Vue 中
1 | ... |
- 在服务端渲染入口
src/entry-server.js
模块中适配 vue-meta
1 | ... |
- 在模板页面index.html中注入 meta 信息
1 | <head> |
- 组件(src/pages/Home.vue) 中,配置 metaInfo
1 | export default { |
页面显示效果如下所示
数据预取和状态管理
接下来我们来了解一下服务端渲染中的数据预取和状态管理。
官方文档中的描述比较枯燥,无法在很短的时间内搞清楚它到底要做什么,所以我们这里通过一个实际的业务需求来引入这个话题。
我们的需求就是:
- 已知有一个数据接口,接口返回一个文章列表数据
- 我们想要通过服务端渲染的方式来把异步接口数据渲染到页面中
这个需求看起来是很简单,无非就是在页面发请求拿数据,然后在模板中遍历出来,如果是纯客户端渲染的话确实就是这样的,但是想要通过服务端渲染的方式来处理的话就比较麻烦了,下面我们来具体实现下
思路分析
也就是说我们要在服务端获取异步接口数据(url:https://cnodejs.org/api/v1/topics
),交给 Vue 组件去渲染。我们首先想到的是在组件的生命周期钩子中请求获取数据渲染页面,既在组件中添加生命周期钩子beforeCreate 和 created(服务端渲染仅支持这两个钩子函数的调用)。
那么下一个问题是如何在服务端发送请求?
依然使用 axios,axios 既可以运行在客户端也可以运行在服务端,因为它对不同的环境做了适配处理,在客户端是基于浏览器的 XMLHttpRequest 请求对象,在服务端是基于 Node.js 中的 http 模块实现,无论是底层是什么,上层的使用方式都是一样的,具体代码如下:
src/pages/Post.vue
1 | <script> |
上述代码的这种做法只会在客户端工作,在服务端渲染中是不会工作的,原因是因为:服务端渲染期间确实会调用created生命周期钩子函数,但是他不会等待其中的异步操作,即便是可以获取到接口返回的异步数据,服务端也不支持这种响应式的操作。
我们可以来验证下,上述代码的运行结果如下:
在浏览中展示的内容并不是通过服务端渲染过来,服务端返回的内容没有列表数据,列表的数据是通过后期客户端方式加载进来的
这里可能会有个疑问,created 是不是在服务端没有调用呢?
通过打印日志可以看到,created 在服务端确实被调用执行了,只是没有作用。
接下来的问题就是:如果我们希望服务端渲染的时候就拿到数据,渲染后的结果就是完整的页面该怎么实现呢?
官方文档给出的解决办法:为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或”状态容器(state container)”中。首先,在服务器端,我们可以在渲染之前预取数据,并将数据填充到 store 中。此外,我们将在 HTML 中序列化(serialize)和内联预置(inline)状态。这样,在挂载(mount)到客户端应用程序之前,可以直接从 store 获取到内联预置(inline)状态。
简单来说:它的核心思路就是把在服务端渲染期间获取的数据存储到外部容器 Vuex 中,然后把容器中的数据同步到客户端,这样就保持了前后端渲染的数据状态同步,避免了客户端重新渲染的问题,所以接下来要做的第一件事就是基于 Vuex 创建容器。
数据预取
- 通过 Vuex 创建容器实例,并挂载到 Vue 根实例
- 安装 vuex
1 | $ npm install vuex |
- 创建 Vuex 容器 store/index.js
1 | import Vue from 'vue' |
- 在 app.js 中将 Vuex 容器挂载到 Vue 根实例
1 | // 通用 entry |
- 在组件中使用 serverPrefetch 触发容器中的 action
1 | <template> |
运行结果如下:
通运行结果可以看出服务端渲染的页面中存在列表数据,但是浏览器中却没有展示出来,原因是因为此时我们拿到的数据只是存储在了服务端的 Vuex 容器当中了,并没有把数据同步到客户端的 Vuex 容器当中,导致两个端数据不同步,合并失败从而使得客户端重新渲染。
将预取数据同步到客户端
- 在服务端渲染应用入口中将容器状态序列化到页面中
接下来我们要做的就是把在服务端渲染期间所获取填充到容器中的数据同步到客户端容器中,从而避免两个端状态不一致导致客户端重新渲染的问题。
- 将容器中的 state 转为 JSON 格式字符串
- 生成代码: window.INITIALSTATE = 容器状态 语句插入模板页面中
- 客户端通过 window.INITIALSTATE 获取该数据
具体实现如下:
- src/entry-server.js
服务端渲染的入口中在router 将可能的异步组件和钩子函数解析完之后增加代码
1 | const { app, router,store } = createApp() |
这步之后,我们所设置的容器 state 被内联到了模板中,通过这种方式传递给客户端
- 在客户端渲染入口中把服务端传递过来的状态数据填充到客户端 Vuex 容器中
- src/entry-client.js
1 | // 客户端启动入口 |
最终运行结果:
服务端渲染好内容后,客户端不在重新渲染,而是接管服务器渲染好的动态的内容交互,客户端通过服务端传递进来的 window.__INITIAL__STATE
将数据同步到客户端的 Vuex
中,数据状态和服务端保持一致,那么也可以在客户端修改数据内容。